/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.catalina.ha.deploy;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.HashMap;
import javax.management.MBeanServer;
import javax.management.ObjectName;

import org.apache.catalina.Container;
import org.apache.catalina.Context;
import org.apache.catalina.Engine;
import org.apache.catalina.Host;
import org.apache.catalina.Lifecycle;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.ha.CatalinaCluster;
import org.apache.catalina.ha.ClusterDeployer;
import org.apache.catalina.ha.ClusterListener;
import org.apache.catalina.ha.ClusterMessage;
import org.apache.catalina.tribes.Member;
import org.apache.tomcat.util.modeler.Registry;

/**
 * <p>
 * A farm war deployer is a class that is able to deploy/undeploy web
 * applications in WAR form within the cluster.
 * </p>
 * Any host can act as the admin, and will have three directories
 * <ul>
 * <li>deployDir - the directory where we watch for changes</li>
 * <li>applicationDir - the directory where we install applications</li>
 * <li>tempDir - a temporaryDirectory to store binary data when downloading a
 * war from the cluster</li>
 * </ul>
 * Currently we only support deployment of WAR files since they are easier to
 * send across the wire.
 * 
 * @author Filip Hanik
 * @author Peter Rossbach
 * @version $Revision: 734560 $
 */
public class FarmWarDeployer extends ClusterListener implements
		ClusterDeployer, FileChangeListener {
	/*--Static Variables----------------------------------------*/
	public static org.apache.juli.logging.Log log = org.apache.juli.logging.LogFactory
			.getLog(FarmWarDeployer.class);
	/**
	 * The descriptive information about this implementation.
	 */
	private static final String info = "FarmWarDeployer/1.2";

	/*--Instance Variables--------------------------------------*/
	protected CatalinaCluster cluster = null;

	protected boolean started = false; // default 5 seconds

	protected HashMap fileFactories = new HashMap();

	protected String deployDir;

	protected String tempDir;

	protected String watchDir;

	protected boolean watchEnabled = false;

	protected WarWatcher watcher = null;

	/**
	 * Iteration count for background processing.
	 */
	private int count = 0;

	/**
	 * Frequency of the Farm watchDir check. Cluster wide deployment will be
	 * done once for the specified amount of backgrondProcess calls (ie, the
	 * lower the amount, the most often the checks will occur).
	 */
	protected int processDeployFrequency = 2;

	/**
	 * Path where context descriptors should be deployed.
	 */
	protected File configBase = null;

	/**
	 * The associated host.
	 */
	protected Host host = null;

	/**
	 * The host appBase.
	 */
	protected File appBase = null;

	/**
	 * MBean server.
	 */
	protected MBeanServer mBeanServer = null;

	/**
	 * The associated deployer ObjectName.
	 */
	protected ObjectName oname = null;

	/*--Constructor---------------------------------------------*/
	public FarmWarDeployer() {
	}

	/**
	 * Return descriptive information about this deployer implementation and the
	 * corresponding version number, in the format
	 * <code>&lt;description&gt;/&lt;version&gt;</code>.
	 */
	public String getInfo() {

		return (info);

	}

	/*--Logic---------------------------------------------------*/
	public void start() throws Exception {
		if (started)
			return;
		Container hcontainer = getCluster().getContainer();
		if (!(hcontainer instanceof Host)) {
			log
					.error("FarmWarDeployer can only work as host cluster subelement!");
			return;
		}
		host = (Host) hcontainer;

		// Check to correct engine and host setup
		Container econtainer = host.getParent();
		if (!(econtainer instanceof Engine)) {
			log.error("FarmWarDeployer can only work if parent of "
					+ host.getName() + " is an engine!");
			return;
		}
		Engine engine = (Engine) econtainer;
		String hostname = null;
		hostname = host.getName();
		try {
			oname = new ObjectName(engine.getName() + ":type=Deployer,host="
					+ hostname);
		} catch (Exception e) {
			log.error("Can't construct MBean object name" + e);
			return;
		}
		if (watchEnabled) {
			watcher = new WarWatcher(this, new File(getWatchDir()));
			if (log.isInfoEnabled()) {
				log.info("Cluster deployment is watching " + getWatchDir()
						+ " for changes.");
			}
		}

		configBase = new File(System.getProperty("catalina.base"), "conf");
		if (engine != null) {
			configBase = new File(configBase, engine.getName());
		}
		if (host != null) {
			configBase = new File(configBase, hostname);
		}

		// Retrieve the MBean server
		mBeanServer = Registry.getRegistry(null, null).getMBeanServer();

		started = true;
		count = 0;

		getCluster().addClusterListener(this);

		if (log.isInfoEnabled())
			log.info("Cluster FarmWarDeployer started.");
	}

	/*
	 * stop cluster wide deployments
	 * 
	 * @see org.apache.catalina.ha.ClusterDeployer#stop()
	 */
	public void stop() throws LifecycleException {
		started = false;
		getCluster().removeClusterListener(this);
		count = 0;
		if (watcher != null) {
			watcher.clear();
			watcher = null;

		}
		if (log.isInfoEnabled())
			log.info("Cluster FarmWarDeployer stopped.");
	}

	public void cleanDeployDir() {
		throw new java.lang.UnsupportedOperationException(
				"Method cleanDeployDir() not yet implemented.");
	}

	/**
	 * Callback from the cluster, when a message is received, The cluster will
	 * broadcast it invoking the messageReceived on the receiver.
	 * 
	 * @param msg
	 *            ClusterMessage - the message received from the cluster
	 */
	public void messageReceived(ClusterMessage msg) {
		try {
			if (msg instanceof FileMessage && msg != null) {
				FileMessage fmsg = (FileMessage) msg;
				if (log.isDebugEnabled())
					log.debug("receive cluster deployment [ path: "
							+ fmsg.getContextPath() + " war:  "
							+ fmsg.getFileName() + " ]");
				FileMessageFactory factory = getFactory(fmsg);
				// TODO correct second try after app is in service!
				if (factory.writeMessage(fmsg)) {
					// last message received war file is completed
					String name = factory.getFile().getName();
					if (!name.endsWith(".war"))
						name = name + ".war";
					File deployable = new File(getDeployDir(), name);
					try {
						String path = fmsg.getContextPath();
						if (!isServiced(path)) {
							addServiced(path);
							try {
								remove(path);
								factory.getFile().renameTo(deployable);
								check(path);
							} finally {
								removeServiced(path);
							}
							if (log.isDebugEnabled())
								log.debug("deployment from " + path
										+ " finished.");
						} else
							log.error("Application " + path
									+ " in used. touch war file " + name
									+ " again!");
					} catch (Exception ex) {
						log.error(ex);
					} finally {
						removeFactory(fmsg);
					}
				}
			} else if (msg instanceof UndeployMessage && msg != null) {
				try {
					UndeployMessage umsg = (UndeployMessage) msg;
					String path = umsg.getContextPath();
					if (log.isDebugEnabled())
						log.debug("receive cluster undeployment from " + path);
					if (!isServiced(path)) {
						addServiced(path);
						try {
							remove(path);
						} finally {
							removeServiced(path);
						}
						if (log.isDebugEnabled())
							log.debug("undeployment from " + path
									+ " finished.");
					} else
						log
								.error("Application "
										+ path
										+ " in used. Sorry not remove from backup cluster nodes!");
				} catch (Exception ex) {
					log.error(ex);
				}
			}
		} catch (java.io.IOException x) {
			log.error("Unable to read farm deploy file message.", x);
		}
	}

	/**
	 * create factory for all transported war files
	 * 
	 * @param msg
	 * @return Factory for all app message (war files)
	 * @throws java.io.FileNotFoundException
	 * @throws java.io.IOException
	 */
	public synchronized FileMessageFactory getFactory(FileMessage msg)
			throws java.io.FileNotFoundException, java.io.IOException {
		File tmpFile = new File(msg.getFileName());
		File writeToFile = new File(getTempDir(), tmpFile.getName());
		FileMessageFactory factory = (FileMessageFactory) fileFactories.get(msg
				.getFileName());
		if (factory == null) {
			factory = FileMessageFactory.getInstance(writeToFile, true);
			fileFactories.put(msg.getFileName(), factory);
		}
		return factory;
	}

	/**
	 * Remove file (war) from messages)
	 * 
	 * @param msg
	 */
	public void removeFactory(FileMessage msg) {
		fileFactories.remove(msg.getFileName());
	}

	/**
	 * Before the cluster invokes messageReceived the cluster will ask the
	 * receiver to accept or decline the message, In the future, when messages
	 * get big, the accept method will only take a message header
	 * 
	 * @param msg
	 *            ClusterMessage
	 * @return boolean - returns true to indicate that messageReceived should be
	 *         invoked. If false is returned, the messageReceived method will
	 *         not be invoked.
	 */
	public boolean accept(ClusterMessage msg) {
		return (msg instanceof FileMessage) || (msg instanceof UndeployMessage);
	}

	/**
	 * Install a new web application, whose web application archive is at the
	 * specified URL, into this container and all the other members of the
	 * cluster with the specified context path. A context path of "" (the empty
	 * string) should be used for the root application for this container.
	 * Otherwise, the context path must start with a slash.
	 * <p>
	 * If this application is successfully installed locally, a ContainerEvent
	 * of type <code>INSTALL_EVENT</code> will be sent to all registered
	 * listeners, with the newly created <code>Context</code> as an argument.
	 * 
	 * @param contextPath
	 *            The context path to which this application should be installed
	 *            (must be unique)
	 * @param war
	 *            A URL of type "jar:" that points to a WAR file, or type
	 *            "file:" that points to an unpacked directory structure
	 *            containing the web application to be installed
	 * 
	 * @exception IllegalArgumentException
	 *                if the specified context path is malformed (it must be ""
	 *                or start with a slash)
	 * @exception IllegalStateException
	 *                if the specified context path is already attached to an
	 *                existing web application
	 * @exception IOException
	 *                if an input/output error was encountered during
	 *                installation
	 */
	public void install(String contextPath, URL war) throws IOException {
		Member[] members = getCluster().getMembers();
		Member localMember = getCluster().getLocalMember();
		FileMessageFactory factory = FileMessageFactory.getInstance(new File(
				war.getFile()), false);
		FileMessage msg = new FileMessage(localMember, war.getFile(),
				contextPath);
		if (log.isDebugEnabled())
			log.debug("Send cluster war deployment [ path:" + contextPath
					+ " war: " + war + " ] started.");
		msg = factory.readMessage(msg);
		while (msg != null) {
			for (int i = 0; i < members.length; i++) {
				if (log.isDebugEnabled())
					log.debug("Send cluster war fragment [ path: "
							+ contextPath + " war: " + war + " to: "
							+ members[i] + " ]");
				getCluster().send(msg, members[i]);
			}
			msg = factory.readMessage(msg);
		}
		if (log.isDebugEnabled())
			log.debug("Send cluster war deployment [ path: " + contextPath
					+ " war: " + war + " ] finished.");
	}

	/**
	 * Remove an existing web application, attached to the specified context
	 * path. If this application is successfully removed, a ContainerEvent of
	 * type <code>REMOVE_EVENT</code> will be sent to all registered listeners,
	 * with the removed <code>Context</code> as an argument. Deletes the web
	 * application war file and/or directory if they exist in the Host's
	 * appBase.
	 * 
	 * @param contextPath
	 *            The context path of the application to be removed
	 * @param undeploy
	 *            boolean flag to remove web application from server
	 * 
	 * @exception IllegalArgumentException
	 *                if the specified context path is malformed (it must be ""
	 *                or start with a slash)
	 * @exception IllegalArgumentException
	 *                if the specified context path does not identify a
	 *                currently installed web application
	 * @exception IOException
	 *                if an input/output error occurs during removal
	 */
	public void remove(String contextPath, boolean undeploy) throws IOException {
		if (log.isInfoEnabled())
			log.info("Cluster wide remove of web app " + contextPath);
		Member localMember = getCluster().getLocalMember();
		UndeployMessage msg = new UndeployMessage(localMember, System
				.currentTimeMillis(), "Undeploy:" + contextPath + ":"
				+ System.currentTimeMillis(), contextPath, undeploy);
		if (log.isDebugEnabled())
			log.debug("Send cluster wide undeployment from " + contextPath);
		cluster.send(msg);
		// remove locally
		if (undeploy) {
			try {
				if (!isServiced(contextPath)) {
					addServiced(contextPath);
					try {
						remove(contextPath);
					} finally {
						removeServiced(contextPath);
					}
				} else
					log.error("Local remove from " + contextPath
							+ "failed, other manager has app in service!");

			} catch (Exception ex) {
				log.error("local remove from " + contextPath + " failed", ex);
			}
		}

	}

	/*
	 * Modifcation from watchDir war detected!
	 * 
	 * @see
	 * org.apache.catalina.ha.deploy.FileChangeListener#fileModified(java.io
	 * .File)
	 */
	public void fileModified(File newWar) {
		try {
			File deployWar = new File(getDeployDir(), newWar.getName());
			copy(newWar, deployWar);
			String contextName = getContextName(deployWar);
			if (log.isInfoEnabled())
				log.info("Installing webapp[" + contextName + "] from "
						+ deployWar.getAbsolutePath());
			try {
				remove(contextName, false);
			} catch (Exception x) {
				log.error("No removal", x);
			}
			install(contextName, deployWar.toURL());
		} catch (Exception x) {
			log.error("Unable to install WAR file", x);
		}
	}

	/*
	 * War remvoe from watchDir
	 * 
	 * @see
	 * org.apache.catalina.ha.deploy.FileChangeListener#fileRemoved(java.io.
	 * File)
	 */
	public void fileRemoved(File removeWar) {
		try {
			String contextName = getContextName(removeWar);
			if (log.isInfoEnabled())
				log.info("Removing webapp[" + contextName + "]");
			remove(contextName, true);
		} catch (Exception x) {
			log.error("Unable to remove WAR file", x);
		}
	}

	/**
	 * Create a context path from war
	 * 
	 * @param war
	 *            War filename
	 * @return '/filename' or if war name is ROOT.war context name is empty
	 *         string ''
	 */
	protected String getContextName(File war) {
		String contextName = "/"
				+ war.getName().substring(0, war.getName().lastIndexOf(".war"));
		if ("/ROOT".equals(contextName))
			contextName = "";
		return contextName;
	}

	/**
	 * Given a context path, get the config file name.
	 */
	protected String getConfigFile(String path) {
		String basename = null;
		if (path.equals("")) {
			basename = "ROOT";
		} else {
			basename = path.substring(1).replace('/', '#');
		}
		return (basename);
	}

	/**
	 * Given a context path, get the config file name.
	 */
	protected String getDocBase(String path) {
		String basename = null;
		if (path.equals("")) {
			basename = "ROOT";
		} else {
			basename = path.substring(1);
		}
		return (basename);
	}

	/**
	 * Return a File object representing the "application root" directory for
	 * our associated Host.
	 */
	protected File getAppBase() {

		if (appBase != null) {
			return appBase;
		}

		File file = new File(host.getAppBase());
		if (!file.isAbsolute())
			file = new File(System.getProperty("catalina.base"), host
					.getAppBase());
		try {
			appBase = file.getCanonicalFile();
		} catch (IOException e) {
			appBase = file;
		}
		return (appBase);

	}

	/**
	 * Invoke the remove method on the deployer.
	 */
	protected void remove(String path) throws Exception {
		// TODO Handle remove also work dir content !
		// Stop the context first to be nicer
		Context context = (Context) host.findChild(path);
		if (context != null) {
			if (log.isDebugEnabled())
				log.debug("Undeploy local context " + path);
			((Lifecycle) context).stop();
			File war = new File(getAppBase(), getDocBase(path) + ".war");
			File dir = new File(getAppBase(), getDocBase(path));
			File xml = new File(configBase, getConfigFile(path) + ".xml");
			if (war.exists()) {
				war.delete();
			} else if (dir.exists()) {
				undeployDir(dir);
			} else {
				xml.delete();
			}
			// Perform new deployment and remove internal HostConfig state
			check(path);
		}

	}

	/**
	 * Delete the specified directory, including all of its contents and
	 * subdirectories recursively.
	 * 
	 * @param dir
	 *            File object representing the directory to be deleted
	 */
	protected void undeployDir(File dir) {

		String files[] = dir.list();
		if (files == null) {
			files = new String[0];
		}
		for (int i = 0; i < files.length; i++) {
			File file = new File(dir, files[i]);
			if (file.isDirectory()) {
				undeployDir(file);
			} else {
				file.delete();
			}
		}
		dir.delete();

	}

	/*
	 * Call watcher to check for deploy changes
	 * 
	 * @see org.apache.catalina.ha.ClusterDeployer#backgroundProcess()
	 */
	public void backgroundProcess() {
		if (started) {
			count = (count + 1) % processDeployFrequency;
			if (count == 0 && watchEnabled) {
				watcher.check();
			}
		}

	}

	/*--Deployer Operations ------------------------------------*/

	/**
	 * Invoke the check method on the deployer.
	 */
	protected void check(String name) throws Exception {
		String[] params = { name };
		String[] signature = { "java.lang.String" };
		mBeanServer.invoke(oname, "check", params, signature);
	}

	/**
	 * Invoke the check method on the deployer.
	 */
	protected boolean isServiced(String name) throws Exception {
		String[] params = { name };
		String[] signature = { "java.lang.String" };
		Boolean result = (Boolean) mBeanServer.invoke(oname, "isServiced",
				params, signature);
		return result.booleanValue();
	}

	/**
	 * Invoke the check method on the deployer.
	 */
	protected void addServiced(String name) throws Exception {
		String[] params = { name };
		String[] signature = { "java.lang.String" };
		mBeanServer.invoke(oname, "addServiced", params, signature);
	}

	/**
	 * Invoke the check method on the deployer.
	 */
	protected void removeServiced(String name) throws Exception {
		String[] params = { name };
		String[] signature = { "java.lang.String" };
		mBeanServer.invoke(oname, "removeServiced", params, signature);
	}

	/*--Instance Getters/Setters--------------------------------*/
	public CatalinaCluster getCluster() {
		return cluster;
	}

	public void setCluster(CatalinaCluster cluster) {
		this.cluster = cluster;
	}

	public boolean equals(Object listener) {
		return super.equals(listener);
	}

	public int hashCode() {
		return super.hashCode();
	}

	public String getDeployDir() {
		return deployDir;
	}

	public void setDeployDir(String deployDir) {
		this.deployDir = deployDir;
	}

	public String getTempDir() {
		return tempDir;
	}

	public void setTempDir(String tempDir) {
		this.tempDir = tempDir;
	}

	public String getWatchDir() {
		return watchDir;
	}

	public void setWatchDir(String watchDir) {
		this.watchDir = watchDir;
	}

	public boolean isWatchEnabled() {
		return watchEnabled;
	}

	public boolean getWatchEnabled() {
		return watchEnabled;
	}

	public void setWatchEnabled(boolean watchEnabled) {
		this.watchEnabled = watchEnabled;
	}

	/**
	 * Return the frequency of watcher checks.
	 */
	public int getProcessDeployFrequency() {

		return (this.processDeployFrequency);

	}

	/**
	 * Set the watcher checks frequency.
	 * 
	 * @param processExpiresFrequency
	 *            the new manager checks frequency
	 */
	public void setProcessDeployFrequency(int processExpiresFrequency) {

		if (processExpiresFrequency <= 0) {
			return;
		}
		this.processDeployFrequency = processExpiresFrequency;
	}

	/**
	 * Copy a file to the specified temp directory.
	 * 
	 * @param from
	 *            copy from temp
	 * @param to
	 *            to host appBase directory
	 * @return true, copy successful
	 */
	protected boolean copy(File from, File to) {
		try {
			if (!to.exists())
				to.createNewFile();
			java.io.FileInputStream is = new java.io.FileInputStream(from);
			java.io.FileOutputStream os = new java.io.FileOutputStream(to,
					false);
			byte[] buf = new byte[4096];
			while (true) {
				int len = is.read(buf);
				if (len < 0)
					break;
				os.write(buf, 0, len);
			}
			is.close();
			os.close();
		} catch (IOException e) {
			log.error("Unable to copy file from:" + from + " to:" + to, e);
			return false;
		}
		return true;
	}

}
